import { $set } from '@feng3d/serialization';
import { BoxGeometry3D, Color, Matrix4, MeshBasicMaterial3D, MeshPhongMaterial3D, Node3D, PerspectiveCamera3D, Plane, PlaneGeometry3D, Scene3D, StatsComponent, Vector3, WebGLRenderer3D } from 'feng3d-three';
import GUI from 'lil-gui';
import '../../extension/controls/OrbitControls3D';

function planesFromMesh(vertices: Vector3[], indices: number[])
{
    // creates a clipping volume from a convex triangular mesh
    // specified by the arrays 'vertices' and 'indices'

    const n = indices.length / 3;
    const result = new Array(n);

    for (let i = 0, j = 0; i < n; ++i, j += 3)
    {
        const a = vertices[indices[j]];
        const b = vertices[indices[j + 1]];
        const c = vertices[indices[j + 2]];

        result[i] = new Plane().setFromCoplanarPoints(a, b, c);
    }

    return result;
}

function createPlanes(n: number)
{
    // creates an array of n uninitialized plane objects

    const result: Plane[] = new Array(n);

    for (let i = 0; i !== n; ++i)
    {
        result[i] = new Plane();
    }

    return result;
}

function assignTransformedPlanes(planesOut, planesIn, matrix)
{
    // sets an array of existing planes to transformed 'planesIn'

    for (let i = 0, n = planesIn.length; i !== n; ++i)
    {
        planesOut[i].copy(planesIn[i]).applyMatrix4(matrix);
    }
}

function cylindricalPlanes(n: number, innerRadius: number)
{
    const result = createPlanes(n);

    for (let i = 0; i !== n; ++i)
    {
        const plane = result[i];
        const angle = i * Math.PI * 2 / n;

        plane.normal.set(
            Math.cos(angle), 0, Math.sin(angle));

        plane.constant = innerRadius;
    }

    return result;
}

const planeToMatrix = (function ()
{
    // creates a matrix that aligns X/Y to a given plane

    // temporaries:
    const xAxis = new Vector3();
    const yAxis = new Vector3();
    const trans = new Vector3();

    return function planeToMatrix(plane: Plane)
    {
        const zAxis = plane.normal;
        const matrix = new Matrix4();

        // Hughes & Moeller '99
        // "Building an Orthonormal Basis from a Unit Vector."

        if (Math.abs(zAxis.x) > Math.abs(zAxis.z))
        {
            yAxis.set(-zAxis.y, zAxis.x, 0);
        }
        else
        {
            yAxis.set(0, -zAxis.z, zAxis.y);
        }

        xAxis.crossVectors(yAxis.normalize(), zAxis);

        plane.coplanarPoint(trans);

        return matrix.set(
            xAxis.x, yAxis.x, zAxis.x, trans.x,
            xAxis.y, yAxis.y, zAxis.y, trans.y,
            xAxis.z, yAxis.z, zAxis.z, trans.z,
            0, 0, 0, 1);
    };
})();

// A regular tetrahedron for the clipping volume:

const Vertices = [
    new Vector3(+1, 0, Number(Math.SQRT1_2)),
    new Vector3(-1, 0, Number(Math.SQRT1_2)),
    new Vector3(0, +1, -Math.SQRT1_2),
    new Vector3(0, -1, -Math.SQRT1_2)
];

const Indices = [
    0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2
];

const Planes = planesFromMesh(Vertices, Indices);
const PlaneMatrices = Planes.map(planeToMatrix);

const GlobalClippingPlanes = cylindricalPlanes(5, 2.5);

const Empty = Object.freeze([]);

let camera: PerspectiveCamera3D;
let scene: Scene3D;
let renderer: WebGLRenderer3D;
let startTime: number;
let stats: StatsComponent;

let object: Node3D;
let clipMaterial: MeshPhongMaterial3D;
let volumeVisualization: Node3D;
let globalClippingPlanes: Plane[];

function init()
{
    camera = new Node3D().addComponent('PerspectiveCamera3D', { fov: 36, aspect: window.innerWidth / window.innerHeight, near: 0.25, far: 16 });

    camera.entity.position.set(0, 1.5, 3);

    scene = new Node3D().addComponent('Scene3D');

    // Lights

    scene.addComponent('AmbientLight3D', { color: { value: 0xffffff }, intensity: 0.3 });

    const spotLight = new Node3D().addComponent('SpotLight3D', { color: { value: 0xffffff }, intensity: 0.5 });
    spotLight.angle = Math.PI / 5;
    spotLight.penumbra = 0.2;
    spotLight.entity.position.set(2, 3, 3);
    spotLight.castShadow = true;
    spotLight.shadow.camera.near = 3;
    spotLight.shadow.camera.far = 10;
    spotLight.shadow.mapSize.width = 1024;
    spotLight.shadow.mapSize.height = 1024;
    scene.entity.addChild(spotLight.entity);

    const dirLight = new Node3D().addComponent('DirectionalLight3D', { color: { value: 0xffffff }, intensity: 0.5 });
    dirLight.entity.position.set(0, 2, 0);
    dirLight.castShadow = true;
    dirLight.shadow.camera.near = 1;
    dirLight.shadow.camera.far = 10;

    dirLight.shadow.camera.right = 1;
    dirLight.shadow.camera.left = -1;
    dirLight.shadow.camera.top = 1;
    dirLight.shadow.camera.bottom = -1;

    dirLight.shadow.mapSize.width = 1024;
    dirLight.shadow.mapSize.height = 1024;
    scene.entity.addChild(dirLight.entity);

    // Geometry

    clipMaterial = $set(new MeshPhongMaterial3D(), {
        color: { value: 0xee0a10 },
        shininess: 100,
        side: 'DoubleSide',
        // Clipping setup:
        clippingPlanes: createPlanes(Planes.length),
        clipShadows: true
    });

    object = new Node3D();

    const geometry = $set(new BoxGeometry3D(), { width: 0.18, height: 0.18, depth: 0.18 });

    for (let z = -2; z <= 2; ++z)
    {
        for (let y = -2; y <= 2; ++y)
        {
            for (let x = -2; x <= 2; ++x)
            {
                const mesh = new Node3D().addComponent('Mesh3D', { geometry, material: clipMaterial });
                mesh.entity.position.set(x / 5, y / 5, z / 5);
                mesh.castShadow = true;
                object.addChild(mesh.entity);
            }
        }
    }

    scene.entity.addChild(object);

    const planeGeometry = $set(new PlaneGeometry3D(), { width: 3, height: 3, widthSegments: 1, heightSegments: 1 });

    const color = new Color();

    volumeVisualization = new Node3D();
    volumeVisualization.visible = false;

    for (let i = 0, n = Planes.length; i !== n; ++i)
    {
        const material = $set(new MeshBasicMaterial3D(), {
            color: { value: color.setHSL(i / n, 0.5, 0.5).getHex() },
            side: 'DoubleSide',

            opacity: 0.2,
            transparent: true,

            // clip to the others to show the volume (wildly
            // intersecting transparent planes look bad)
            clippingPlanes: clipMaterial.clippingPlanes
                .filter(function (_, j)
                {
                    return j !== i;
                })

            // no need to enable shadow clipping - the plane
            // visualization does not cast shadows

        });

        const mesh = new Node3D().addComponent('Mesh3D', { geometry: planeGeometry, material });
        mesh.matrixAutoUpdate = false;

        volumeVisualization.addChild(mesh.entity);
    }

    scene.entity.addChild(volumeVisualization);

    const ground = new Node3D().addComponent('Mesh3D', {
        geometry: planeGeometry,
        material: $set(new MeshPhongMaterial3D(), {
            color: { value: 0xa0adaf }, shininess: 10
        })
    });
    ground.entity.rotation.x = -Math.PI / 2;
    ground.entity.scale.multiplyScalar(3);
    ground.receiveShadow = true;
    scene.entity.addChild(ground.entity);

    // Renderer

    const container = document.body;

    renderer = scene.addComponent('WebGLRenderer3D');
    renderer.shadowMap.enabled = true;
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);
    window.addEventListener('resize', onWindowResize);
    container.appendChild(renderer.domElement);
    // Clipping setup:
    globalClippingPlanes = createPlanes(GlobalClippingPlanes.length);
    renderer.clippingPlanes = Empty as any;
    renderer.localClippingEnabled = true;

    // Stats

    stats = scene.addComponent('StatsComponent');
    container.appendChild(stats.dom);

    // Controls

    const controls = scene.addComponent('OrbitControls3D', { camera, domElement: renderer.domElement });
    controls.minDistance = 1;
    controls.maxDistance = 8;
    controls.target.set(0, 1, 0);
    controls.update();

    // GUI

    const gui = new GUI();
    const folder = gui.addFolder('Local Clipping');
    const props = {
        get Enabled()
        {
            return renderer.localClippingEnabled;
        },
        set Enabled(v)
        {
            renderer.localClippingEnabled = v;
            if (!v) volumeVisualization.visible = false;
        },

        get Shadows()
        {
            return clipMaterial.clipShadows;
        },
        set Shadows(v)
        {
            clipMaterial.clipShadows = v;
        },

        get Visualize()
        {
            return volumeVisualization.visible;
        },
        set Visualize(v)
        {
            if (renderer.localClippingEnabled)
            { volumeVisualization.visible = v; }
        }
    };

    folder.add(props, 'Enabled');
    folder.add(props, 'Shadows');
    folder.add(props, 'Visualize').listen();

    gui.addFolder('Global Clipping')
        .add({
            get Enabled()
            {
                return renderer.clippingPlanes !== Empty;
            },
            set Enabled(v)
            {
                renderer.clippingPlanes = v ? globalClippingPlanes : Empty as any;
            }
        }, 'Enabled');

    // Start

    startTime = Date.now();
}

function onWindowResize()
{
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize(window.innerWidth, window.innerHeight);
}

function setObjectWorldMatrix(object: Node3D, matrix: Matrix4)
{
    // set the orientation of an object based on a world matrix

    const parent = object.parent;
    scene.entity.updateMatrixWorld();
    object.matrix.copy(parent.matrixWorld).invert();
    object.applyMatrix4(matrix);
}

const transform = new Matrix4();
const tmpMatrix = new Matrix4();

function animate()
{
    const currentTime = Date.now();
    const time = (currentTime - startTime) / 1000;

    requestAnimationFrame(animate);

    object.position.y = 1;
    object.rotation.x = time * 0.5;
    object.rotation.y = time * 0.2;

    object.updateMatrix();
    transform.copy(object.matrix);

    const bouncy = Math.cos(time * 0.5) * 0.5 + 0.7;
    transform.multiply(
        tmpMatrix.makeScale(bouncy, bouncy, bouncy));

    assignTransformedPlanes(
        clipMaterial.clippingPlanes, Planes, transform);

    const planeMeshes = volumeVisualization.children;

    for (let i = 0, n = planeMeshes.length; i !== n; ++i)
    {
        tmpMatrix.multiplyMatrices(transform, PlaneMatrices[i]);
        setObjectWorldMatrix(planeMeshes[i], tmpMatrix);
    }

    transform.makeRotationY(time * 0.1);

    assignTransformedPlanes(globalClippingPlanes, GlobalClippingPlanes, transform);

    stats.begin();
    renderer.render(scene, camera);
    stats.end();
}

init();
animate();
